Audio Defence — Windows and Mac port

A port of Audio Defence: Zombie Arena (Somethin' Else, iOS, 2015) to Windows and the Mac: the audio-only shooter you play by listening, turning towards a zombie you can hear and firing before it reaches you. Endless mode, the challenge worlds, the armory, the tarot cards, the Zombiepedia — and the whole of it through a screen reader, because that is how the original was meant to be played.

The original was pulled from the App Store years ago and never came to anything else. It needs a phone you may no longer own, running an OS that will not install it. This port exists so it can be played on a desktop, with a keyboard and NVDA or VoiceOver, by people who cannot see the screen and never needed to.

It is playable from the logo to the last challenge. If you find something that sounds wrong, say so — most of what is fixed in here was found by someone playing it and describing what they heard.

Which copy this is

This repository holds the original port, and keeps it that way. What is here is meant to play as Audio Defence: Zombie Arena played — the waves, the zombies, the weapons and what they cost, the sounds, and the quirks, down to the bugs the original had. A change belongs here when it brings the game closer to the original, or when it makes the original reachable through a screen reader and a keyboard. A change that adds something the original never had does not.

Work that adds to the game happens in AudioDefence, which this copy was taken from. The two share everything up to the point they parted; from there, that one grows and this one does not.

This build also does not update itself — there is nothing here that looks for a newer version, and nothing that downloads one.

Accessibility

The port is built for a screen reader, not adapted to one afterwards. It speaks through NVDA when NVDA is running; through JAWS, ZDSR, Narrator, ZoomText, System Access, Window-Eyes, PC-Talker, Boy PC Reader or Sense Reader when one of those is, tried in that order (through the Prism library); and through the SAPI 5 voice when none is — nothing else is required, and there is no visual mode worth using. A screen reader started or closed while the game runs is followed within a few seconds. The game is the same whichever speaks: with SAPI 5 you get every screen and the spoken game exactly as an NVDA player does.

That choice is automatic. Settings → Speech → Speech output picks one instead — NVDA, JAWS, ZDSR, Narrator, ZoomText, System Access, Window-Eyes, PC-Talker, Boy PC Reader, Sense Reader or SAPI 5 — with Enter for the next and Shift+Enter for the previous, and Automatic, the default, at the start of the list. The one you pick is the only one used: while it is not running the game is silent, even with another screen reader running. The row itself always tells you what you picked, through the automatic choice when the one you picked cannot speak ("JAWS is not running, so the game will be silent until it is"), so you can step on to another without being left in silence.

While SAPI 5 is what speaks — chosen, or Automatic with no screen reader running — five more rows follow it in the same tab, for SAPI 5 itself. They come and go by themselves as a screen reader starts or closes:

rowwhat it does
SAPI 5 voicethe voice set in Control Panel (the default), then every installed voice
SAPI 5 rate-10 to 10, starting where Control Panel has it
SAPI 5 rate boostfaster again than the rate allows — only for a voice that can, which the game tries once per voice to find out
SAPI 5 pitch-10 to 10, 0 being the voice's own
SAPI 5 volumein steps of 10%, starting where Control Panel has it

Enter and Shift+Enter change each one, and each change is said in SAPI 5 at the new setting, even while NVDA speaks the rest, so you hear what you chose. Reset all settings puts all five back to Control Panel's.

On the Mac the same page lists Automatic, VoiceOver and System voice: VoiceOver when it is on, the system voice when it is not, and the system voice's own five rows — voice, rate, pitch and volume, with no rate boost — in place of SAPI 5's. See On the Mac.

Every screen the original offered a VoiceOver user is here, read in the order VoiceOver read it, with the same labels and hints, and a good number of places where the original said the wrong thing or nothing at all have been fixed. They are all listed under How faithful this is.

How this was made

This is a vibe-coded project, and it says so plainly because the method is the interesting part.

Nobody had the source. What existed was the shipped iOS app: an arm64 binary, its NIBs, its property lists, and 915 .m4a files. Everything in audiodefence/ was written by reading that binary method by method and writing the Python next to it — Claude (Opus 5, at its highest reasoning effort) did the reading and the writing, prompted by a human who cannot see the screen and who tested every line of it by ear.

The rule was: copy what the game does, not what it ought to do. Its bugs included. Every ported method carries the address it was written against, so any line can be checked against the disassembly, and every deliberate departure is written down with the address of the method it came from. That rule is for the game itself; what the port adds to it is listed on its own, under How faithful this is.

The toolchain that made it possible lives in tools/ and its output in analysis/ — a disassembler, an Objective-C class and selector dump, a digest view that condenses a method to its sends and branches, a NIB layout reader, a resource lister, and an extractor for the HRTF that was embedded in the binary. The game's own binaural impulse responses are in assets/hrtf/, which is why it sounds like the original rather than like a generic 3D mixer.

Two lessons paid for in bugs, recorded here because they are the whole method in miniature:

Whose work this is

game/ holds the original's own data and audio — the narration, the zombies, the weapons, the music, the playlists that arrange them. That is Somethin' Else's work, not ours. It is in the repository so the port can run and be built from a clone, and a build made by compiler.py carries it too.

Nothing of theirs is claimed here, this project is not affiliated with them, and if they want it taken down it comes down.

Getting it

There are two ways to play, and only one of them needs Python.

The built game is on this repository's Releases page: a folder with AudioDefenceOriginal.exe in it, and the game's own data beside it. Unzip it and run the executable — nothing to install, no Python, 64-bit Windows and a pair of headphones. readme.html and changelog.txt are in the same folder.

You only have to do that once.

On the Mac it is the same folder with AudioDefenceOriginal.app in it instead, in AudioDefenceOriginalMac-<version>.zip on the same release — see On the Mac.

From source is everything below: the repository as it stands, run with the Python you have. That is the one to take if you want to read the code, change it, or build the executable yourself.

Requirements

These are for running from source. A built game from Releases needs none of them.

64-bit Python 3.12 or newer on Windows. The simplest way to get it is the Python install manager from python.org, which is what that site now leads with: install it, then

py install 3.14

and py is the command for everything below. A traditional python.org installer works just as well — it brings py with it — so if you already have Python, you have what you need.

Then the packages to play:

py -m pip install pygame-ce numpy av comtypes prismatoid
packagewhat needs it
pygame-cethe window, the keyboard and the frame loop (audiodefence/__main__.py, ui/)
numpythe audio maths: decoding, mixing, the Freeverb reverb (s3d/)
av (PyAV)decodes the game's .m4a sounds (s3d/decoder.py) — without it no sound plays
comtypesthe SAPI 5 voice, used when no screen reader is running (platform/speech.py); skip it if you always play with NVDA
prismatoidPrism, for speech through the screen readers other than NVDA — JAWS, ZoomText, System Access and the rest (platform/speech.py); it brings cffi with it. Without it those players hear the SAPI 5 voice instead; skip it if you always play with NVDA

Nothing else is imported outside the standard library. OpenAL Soft (vendor/openal/soft_oal.dll) and the NVDA controller client (vendor/nvda/nvdaControllerClient64.dll) ship with the repository, so there is nothing to install for either, and no system OpenAL is used.

One more package is needed only to redo the reverse engineering, never to play:

py -m pip install capstone
packagewhat needs it
capstonethe arm64 disassembler (tools/disasm.py, and tools/extract_hrtf.py)

numpy and av are used by the tools as well — numpy by tools/build_hrtf.py and tools/reverb_calibrate.py, av for the sound durations in tools/resources.py — but you already have both for playing.

And one more only if you want to build an executable rather than run from source (Building an executable, below):

py -m pip install pyinstaller

Running from source

py AudioDefence.py

That is the whole story: the logo, the opener, then the main menu. The launcher can be double-clicked, and it takes the options below:

py AudioDefence.py --endless
py AudioDefence.py --challenge tutorial_1
py AudioDefence.py --help

Settings, saves and the log live in %APPDATA%\AudioDefenceOriginal (on the Mac, ~/Library/Application Support/AudioDefenceOriginal), in three files:

filewhat is in it
save.jsonprogress: coins, diamonds, weapons, power-ups, missions, challenges, statistics
settings.jsoncontrol scheme, button mode, turn sensitivity, menu arrows, cursor memory, tutorial text, the announcer, the game volume, menu music volume, how strong the vibration and the trigger feel are, and whether hints name keys or controller buttons, and which controller's, and the speech output and SAPI 5's voice, rate, rate boost, pitch and volume
keys.jsonthe key bindings, and each kind of controller's, by its name

Deleting the folder starts a fresh profile — the first run then begins on Gyro with the default key bindings.

On the Mac

The Mac build is the same port — the same engine, the same game data, the same OpenAL Soft and the game's own HRTF — with the Mac's own speech, folders and app. Nothing in the game itself asks which it is on: audiodefence/platform/host.py holds every choice that differs.

WindowsMac
the gameAudioDefenceOriginal.exe, with game/ beside itAudioDefenceOriginal.app, with the game's data inside it
speechNVDA, the other screen readers through Prism, SAPI 5VoiceOver, then the system voice
settings, saves, log%APPDATA%\AudioDefenceOriginal~/Library/Application Support/AudioDefenceOriginal
OpenAL Softvendor/openal/soft_oal.dllvendor/openal-mac/libopenal.dylib
release zipAudioDefenceOriginal-Win-<version>.zipAudioDefenceOriginalMac-<version>.zip
leaving the gameAlt+F4, or QuitCmd+Q, or Quit

Playing the built game

Unzip AudioDefenceOriginalMac-<version>.zip and move the AudioDefence folder somewhere of your own — your Applications folder will do — then open AudioDefenceOriginal.app in it. The app is not signed with an Apple developer certificate, so the first time macOS refuses to open it: open System Settings → Privacy & Security, and choose Open Anyway for AudioDefenceOriginal near the bottom. You only do that once. It runs on Apple silicon Macs.

Move the folder before you first open the game. An app opened where it was downloaded is run by macOS from a temporary copy, which is not where it should live.

VoiceOver

With VoiceOver on, the game speaks through it, in your own voice and at your own rate, and a braille display shows the same lines. It speaks to VoiceOver by AppleScript, which VoiceOver allows only when asked to: in VoiceOver Utility → General, tick Allow VoiceOver to be controlled with AppleScript. The first time the game speaks, macOS asks whether AudioDefenceOriginal may control VoiceOver — say OK. Until both are done the game still speaks, as announcements VoiceOver reads while the game's window has focus, but they can be cut short by VoiceOver's own speech.

VoiceOver's Quick Nav takes the arrow keys for itself, and the game needs them: if the arrows seem to do nothing, press Left and Right arrow together to turn Quick Nav off.

With VoiceOver off, the game speaks with the system voice — the one set in System Settings → Accessibility → Spoken Content — and Settings → Speech lists its voice, rate, pitch and volume, as it lists SAPI 5's on Windows.

In the menus Command works as Control does with the arrows: Command with the menu's arrows goes to the first or last element, and with the other pair to the first or last tab.

Running from source

The Mac uses uv for Python and the packages, which the repository's pyproject.toml and uv.lock pin:

brew install uv
uv run AudioDefence.py

uv run makes .venv with the right Python and packages the first time; the options are the same as on Windows. pygame-ce, numpy and av are the same three, pyobjc-framework-cocoa takes the place of comtypes and prismatoid for speech, and PyInstaller comes with them for building. AudioDefence.command can be double-clicked in Finder to do the same.

Building the app

uv run compiler.py

or double-click compiler.command. It is the same compiler with the same menu and flags, less the one-file build, which on a Mac would unpack itself on every launch. It leaves dist/AudioDefenceOriginal holding AudioDefenceOriginal.app, readme.html, changelog.txt and license.txt, and zips it into dist/AudioDefenceOriginalMac-<version>.zip, keeping the app's links and execute bits so Finder unzips a working app. The game's data goes inside the app, at Contents/Resources/game, less the original's iOS executable and its code signature, and the app is signed again, ad hoc, once it is complete.

Put the Mac zip on the same release as the Windows one, in either order. Keep the names exactly as the compiler makes them, so each platform's zip can be told from the other — and so that neither shares a name with a release of the repository this copy was taken from.

vendor/openal-mac/libopenal.dylib is OpenAL Soft 1.25.2 built for arm64, the same version as the Windows DLL. tools/build_openal_mac.sh rebuilds it from source (it needs cmake and the Xcode command line tools), for an Intel Mac with --arch x86_64.

Controls

In the menus, the port stands in for VoiceOver: it reads the elements of a screen the way VoiceOver reads them, with their labels, hints and "button".

keywhat it does in the menus
Right / Tabnext element
Left / Shift+Tabprevious element
Ctrl+Right / Ctrl+Left, Ctrl+Tab / Ctrl+Shift+Tab, End / Homelast / first element
Down / Upnext / previous tab or category
Ctrl+Down / Ctrl+Uplast / first tab or category
Enteractivate
Shift+Entera row's second action, where it has one
Escape / Backspaceback
Page Up / Page Downmenu music louder / quieter

Hold a key that moves one element — the arrows, Tab or Shift+Tab — and it repeats, so a long list can be walked through without tapping. It starts repeating after about a third of a second. Nothing else repeats: a jump to an end has nowhere to go, Enter must act once, and a key held in a game belongs to the game.

In a game, these are the defaults; all of them can be rebound in Settings → Keyboard, and "Restore default keys" puts them back.

keywhat it does in a game
Spacefire: tap for a single shot, hold for continuous fire
Left Ctrl or Right Ctrlmelee
W, or Up arrow under Gesturenext weapon
R, or Down arrow under Gesturereload
Left / Right arrowturn
Escapepause: Resume, Restart challenge (in a challenge), End Game
Enterskip the narration
Tread the challenge timer

Those two follow the control scheme, because the scheme changes what the action is. Under Gesture you are swiping: up switches weapon, down reloads, and the arrows stand for the swipes. Under Button you are pressing the four corner buttons, where nothing is directional, so they are letters — W and R. Rebinding either one changes it for the scheme you are in and leaves the other alone; the rest of the keys are one binding for both.

An action can hold more than one key. On a binding row, Enter adds a key, Shift+Enter replaces every key that action has, and Delete removes the one you added last — an action is never left with no key at all. Key names are spoken the way people say them, so the two Enter keys are "Enter" and "Numpad Enter".

The arrows that move through a screen are a setting: Left and Right by default, or Up and Down if you prefer them, in Settings → Miscellaneous → Menu arrows. Whichever pair you choose, Ctrl with it jumps to the first or last element, and Tab, Shift+Tab, Home and End work either way.

The other pair changes tab. In the armory, Down and Up step through Weapons, Loadout and Powerup without walking to the bottom of the list first, and each one is named before it reads what you land on; Settings opens straight inside Aiming, and the same two keys move it to Controls, Sound, Keyboard and Miscellaneous — there is no list of categories to go through first. Choose Up and Down for moving through a screen and the two swap over, so the tabs land on Left and Right. Ctrl with the tab pair jumps to the first or last tab, exactly as Ctrl with the other pair jumps to the first or last element; Tab stays with the elements, so Ctrl+Tab and Ctrl+Shift+Tab jump to the last and first element from wherever you are. The ends hold rather than wrap, as everywhere else.

Settings speaks every change and clicks like the original's buttons. Escape leaves it; the turn sensitivity and the key bindings live in Aiming and Keyboard. Settings → Miscellaneous → Reset all settings puts every setting back to how a new profile starts, except your key bindings, which Keyboard has its own Restore default keys for. Quit is on the main menu.

With a game controller

A controller works in the menus and in play: a DualSense, a DualShock 4, an Xbox or Switch Pro controller, or most others Windows recognises. Plug it in before or after starting the game; it says so when one connects or goes. The buttons are named as your controller names them — Cross and R2 on a PlayStation pad, A and RT on an Xbox one.

In the menus it stands in for the keys:

controllerwhat it does in the menus
D-pad or either stickthe arrows: move through the screen, and change tab on the other pair
Cross (A)Enter: activate
Square (X)Shift+Enter: a row's second action
Circle (B)Escape: back
L1 / R1previous / next row, without reaching for the D-pad
Cross (A) held, with a direction or L1 / R1the first or last row, or the first or last tab
L2 / R2menu music quieter / louder
Triangle (Y)Delete, on a key binding row

In a game:

controllerwhat it does in a game
either stick, sidewaysturn — a small push turns slowly, all the way as fast as the arrow keys
D-pad left / rightturn, at the same speed as the arrow keys (Alternate turn left / right — the only turning you can rebind)
R2fire
R1melee
L1, or a stick flicked up or D-pad up under Gesturenext weapon
L2, or a stick flicked down or D-pad down under Gesturereload
Optionspause, and Options again to resume
Cross (A)skip the narration
Squareread the challenge timer

Under Gesture a stick flicked up or down is the swipe, as the Up and Down arrows are on the keyboard; a stick pushed more sideways than up or down only turns, so turning does not switch weapons by accident. Shaking the controller is shaking the phone: under Gesture it swings your melee weapon, as it did in the original, and under Button it does nothing, as it did not. It needs a pad that can feel movement — a DualSense, a DualShock 4 or a Switch Pro.

A controller that can vibrate lets you feel the game:

A DualSense on USB goes further: its grips play the game's own heartbeat — the very recording you hear, felt in your hands — and its own knocks, thuds and rumbles for the rest, through the fine haptics PlayStation games use rather than plain rumble. Over Bluetooth Windows does not offer that, and it rumbles like any other pad. On a DualSense the triggers change too while you play: R2 is shaped like a pistol's trigger: a long take-up with nothing in it, then the wall, where it holds; pressing through the wall breaks it and the shot goes at the break, not when you first meet it. Letting it back out to just under the wall is the reset, and it fires again from there, so you can shoot without letting go all the way. Under Button, L2 pulls against a light spring where it reloads. In the menus and on the pause screen they are plain again, and they are set back when the game closes.

Settings → Miscellaneous → Fine haptics (on by default) chooses how a controller that has both plays what you feel: through the fine haptics in its grips, or through its motors like any other pad. Turn it off to feel a DualSense as an ordinary controller. With no such controller connected, the motors are used whatever it says.

Settings → Miscellaneous sets how strong Joystick vibration and the Trigger feel are: Off, Light, Medium (the default) or Strong, with Enter for the next and Shift+Enter for the previous. Each step of Joystick vibration gives a pulse at the new strength so you can feel it, and each step of the Trigger feel puts that feel on the triggers for eight seconds so you can squeeze R2 and try it there and then. The Trigger feel is only for a DualSense — no other controller has one.

Settings → Joystick names the controller that is connected, and lets you change which button does what, the way Keyboard does for keys: Enter (Cross) on an action adds a button, Shift+Enter (Square) replaces them all, Delete (Triangle) removes the last one, and Escape on the keyboard cancels. Next weapon and Reload are set for the control scheme you are in, as on the keyboard. A stick pushed sideways cannot be bound — it turns — and neither can the PS or Xbox button, which Windows or Steam often keeps. Each kind of controller keeps its own buttons, by the name it gives itself — a DualSense's, a DualShock 4's, an Xbox pad's — made from the defaults the first time it is connected, so setting up one never changes another. With two or more kinds connected, the Controller row says which one's buttons are listed ("DualSense Wireless Controller, 1 of 2"), and Enter or Shift+Enter goes to the next or previous. Restore default buttons puts that controller's buttons back, and Reset all settings leaves them alone. With no controller connected the buttons are not listed. If Steam is running it may take the controller over and present it as an Xbox pad; the game still works, but for your own controller's button names, turn Steam Input off for the game.

Settings → Miscellaneous → Names in hints and tutorial chooses whether the hints and the tutorial text name the keyboard's keys (the default) or the connected controller's buttons, as that controller calls them: "Press Cross to copy the results", "use the R2 button to fire your weapon" on a PlayStation pad, "Press A" and "RT" on an Xbox one. Enter or Shift+Enter switches it. So do the other lines that name a key: the tab and category keys ("L1 and R1 change tab, D-pad left and right move through it"), the Button and Gesture rows, and "Press Cross to skip intro". With two or more kinds of controller connected, Controller for names under it chooses which one. With no controller connected the row is dimmed and it is Keyboard keys: starting the game without one, or unplugging the last one wherever you are, sets it back to Keyboard keys and saves that, so choose Controller buttons again when you next play with one.

Sharing a result

Every screen at the end of a run has a Copy results button, read straight after the results and before the screen's other buttons: after an endless game, after a challenge you completed, and after one you failed. It puts a short list on the clipboard as plain text, ready to paste into a message, and says "Results copied to clipboard." After an Endless game it looks like this:

Audio Defence Endless Statistics

Tarot cards: Electric Shield and Zombies With Helmets!
Coins Earned: 7802
Diamonds Earned: 14
Score: 11882144
Kills: 162
Accuracy: 76.53 %
Survival time: 17:04
Maximum combo: 483

After a challenge the heading is Audio Defence Challenge Statistics, then the challenge's name and whether you completed or failed it, then the stars, rewards and statistics that screen shows. There are no tarot cards in a challenge.

The lines are taken from the screen itself rather than worked out again: each results screen reads exactly these lines, one row each, and only the heading is not on it, since the screen has its own title. The game's version is never in it. The original had one way to share a score, a Twitter sheet, which needed an account and an iPhone; this needs neither.

The failed screen used to tell you nothing at all — the original shows a tip, and two buttons. It now reads your results first, the same kills, accuracy and time the completed screen shows, so a run you lost can be compared with one you won, and so the copy is not handing you figures you were never told. The tip follows them.

How faithful this is

Faithful here means the game itself. What you play is the original's, and it stays that way: the waves, the zombies, the weapons and what they cost, the challenges, the sounds, and the quirks that shape how it plays. The port does add things around the game — Copy results, the settings a desktop needs, the speech — and those are here; what it will not grow is anything inside the game the original never had. Whatever was added follows the original's concept and sits beside what the original has, rather than changing it. You can hear the line in Settings: the original's own rows work as they always did, and only the rows the port added step through their values with Enter and Shift+Enter. The additions are listed together under New in the port, apart from the changes to what the original already had.

The port is written method by method against the original's arm64 disassembly, and the rule it follows is to copy what the game does rather than what it ought to do — its bugs included. Every place it departs from that is written down in docs/PORTING_NOTES.md, with the address of the method it came from, so any of them can be checked against the binary or put back. Listed below are the ones you would notice while playing; the rest are internal — analytics that only log locally, a sanity check that only printed, an undefined return value nothing reads.

There are 125 divergences and 15 original quirks kept on purpose in the notes, of which 72 are listed here.

1. Windows standing in for a phone

The original is played by touch and device motion. None of that exists here, so the input layer is the port's own work and has no counterpart in the binary.

2. Where the original's screen-reader path was wrong

These are the largest group. The original ships a VoiceOver path that in places says the wrong thing, says nothing, or says something no keyboard player can act on.

3. Original bugs fixed

Faults in the game's own logic, not in how it describes itself. Each was read against the disassembly first.

4. Sound and music

5. Engine and presentation

6. New in the port

Things the original never had. Each one sits beside the original's own screens and rows rather than replacing them.

Original quirks kept on purpose

Fifteen remain. Each is a design decision rather than a fault, a change that would alter how the game plays or sounds rather than what it tells you, or something with no observable effect at all.

The ones you can notice:

The other seven have no observable effect: a message to a nil receiver, a return value every caller discards, a hardcoded sound speed no data can reach, a navigation call the port never makes, a dispatch whose timing works out the same either way, a button left in the state its branch wanted, and a dictionary keyed by display name.

What has been taken out

A few things the original shipped are gone rather than ported. Each is either something that cannot work outside an iPhone in 2015, or a dead end that would only cost you a keypress to discover.

Three keys: the magic tap on F2, the repeat key on F1, and Space. VoiceOver has a two-finger double tap that a screen can answer with its most obvious action, and the port had put it on F2. Every screen that answered it did so by pressing a button the cursor already reaches — Play, Play again, Next mission — so it was a second way to do something the menu does anyway, and on the revive screen after a death it was a second way to end the run without meaning to. docs/PORTING_NOTES.md records what each screen's did. F1 read the focused element out again, which your screen reader's own review keys already do. Space activated whatever the cursor was on, beside Enter; it is now the fire key in a game and nothing else. Enter, and the number pad's Enter, activate.

The armory's Currency tab. It was built to hold four "free coins" offers — follow the game on Facebook, follow it on Twitter, look at the studio's other games, rate it on the App Store — each of which opened a web page. None of them was ever wired up: the tab's table asks a products array for its row count and nothing in the binary ever fills that array in, so the tab was blank on every device that ever ran the game. For a while this port kept it and had it explain itself, with a line that read:

This tab is empty. It offered free coins for following the game on social media. Those links are long dead, and the original never filled this tab in either.

which is a fair description of a tab you should not have to visit. It is now removed, leaving Weapons, Loadout and Powerup. Two knock-on changes came with it: the "Not enough Coins!" alert no longer offers its "More coins" button, which opened that tab, and the last sentence of that alert — "You can also get coins in the Armory's currency tab" — is not shown, since it would be pointing at nothing. The two ways of earning coins that do work are still named.

Sharing and Game Center. The Twitter and Facebook buttons on the game-over screen, and the score reporting to Game Center, are removed. There is no Game Center on Windows, and the sharing buttons opened apps that are not here.

The "more games" screen. A catalogue of the studio's other iOS titles, reachable from the stats portal. It advertised apps you cannot install from a machine that cannot run them.

Unreachable screens are simply not ported, which is a different thing from removal — the game still contains them, nothing can open them, and the port does not implement them: the scenario roulette and the story screen (nothing calls them, no NIB action reaches them), the cheat screen (its button is hidden), the enemy-unlock popup and the score-feedback view (never allocated). docs/PORTING_NOTES.md lists them with the reasoning.

Touch handlers are ported but never called, which is also not removal. A few of the original's methods are here in full with nothing to reach them, because a keyboard sends no fingers: touchesMoved: 0x10008a5a4, the weapon buttons' touch-down and touch-up 0x1000a9d78 and 0x1000a9d7c, the shake motionEnded: 0x10005a108. hitByProjectile: 0x100061098 is here for a different reason: nothing in the original calls it either, because a rocket's damage is dealt by solveExplosionOfProjectile 0x1000c6360 instead. They stay as the record of what the original did, next to the keyboard code that stands in for them. That is the line the magic tap fell the wrong side of: it was not a handler waiting for input the port never sends, it was a gesture the port had invented a key for, so when the key went there was nothing left to keep — and post_announcement, which existed only to say "no magic tap available on this screen", went with it.

What is in the repository

AudioDefence.py     the launcher
AudioDefence.command  the launcher, double-clicked on the Mac (uv run)
.gitattributes      LF for text, hands off the binaries
audiodefence/       the port
    s3d/            the S3D audio engine on OpenAL Soft: HRTF, playlists,
                    streaming decoder, the original Freeverb reverb bus
    platform/       run loop, timers, notifications, user defaults, C rand,
                    speech, the key map;
                    host.py, every choice between Windows and the Mac, and
                    macspeech.py, VoiceOver and the Mac's system voice
    game/           gameplay: enemies, bricks, weapons, power-ups, missions,
                    challenges, inventory, stats, the gameplay controllers
    ui/             the screens, built from the NIBs, and the VoiceOver
                    stand-in that reads them
    app.py          the app delegate: launch, menu music, navigation
compiler.py          builds the executable, and the release zip (see below)
compiler.command    the same, double-clicked on the Mac
pyproject.toml      the packages, for uv; uv.lock pins them
VERSION             the release tag, e.g. 26.09.20-2, built into the executable
game/               the original game's own files (see below)
assets/hrtf/        the HRTF recovered from the binary
analysis/           the reverse engineering: disassembly, digests, dumps
docs/               PORTING_NOTES.md, GAME_STRUCTURE.md
tools/              reverse-engineering and asset tools
vendor/             OpenAL Soft (the Windows DLL, and the Mac dylib in
                    openal-mac/), the NVDA controller client

game/ — the original

The contents of the IPA's app bundle, unwrapped. The port reads them in place: the plists, the en.lproj strings, the S3D playlist models under game/meta/ and the 918 sound files under game/sounds/, in the layout the bundle already has. Nothing is copied and nothing is converted, so a clone has everything the game needs.

The port reads it from game/, unless --game PATH (or the AUDIODEFENCE_GAME environment variable) points somewhere else. The first line in the log says which one it took.

Not all of it is used: the port never draws, so the .png artwork, the .ttf fonts and html/ are along for the ride, and _CodeSignature/ is Apple's tamper manifest. They are kept so game/ stays the complete original. Do not be misled by the underscore in game/sounds/_weapons/ or in the _fight / _zombie sub-playlists — those are game data, not metadata.

analysis/ — the reverse engineering

46 MB of generated material, and the reason the port can claim to be faithful:

filewhat is in it
disasm/<unit>.sfull annotated listings of all 7,692 functions, one file per class
digest/<unit>.txtthe condensed pseudo-code view of the same functions
bin/the arm64 and armv7 slices pulled out of the fat binary
objc.json, classdump.h, cxx_classes.txt400 Objective-C classes, 43 C++ classes with vtables
xrefs.json, functions.tsvper function: calls, callers, selectors, strings, ivars, float constants
data/nibs/*.txt150 NIB dumps: view trees, frames, texts, VoiceOver labels, outlets, actions
data/plists/*.json, playlists.json, sounds.tsvthe game's data as JSON, the 208 playlists, the sound inventory
data/embedded_hrtf.datthe HRTF table extracted from the binary

The game never reads any of it. It is all rebuildable from game/, and it is what every address comment in the port points into.

Command line options

None of them is needed to play; they exist so a session can be started at a particular point, and so automated runs can finish on their own.

optionwhat it does
--endlessstart an endless game directly. This skips the play menu (so it works before Endless is unlocked by finishing tutorial_5) and skips the tarot cards, so no card modifiers are in play.
--challenge NAMEstart one challenge by its plist name (tutorial_1, arena1_3, …). It skips the selector, so the challenge's weapon and completion requirements are not checked — your saved inventory is still what you play with.
--game PATHread the game's data from somewhere other than game/ (see below).
--log-level debuglog every decision the engine makes, not just the milestones. The first thing to try when something misbehaves.
--muteset the listener's gain to zero: the game runs in silence.
--no-speechnever speak, so a test run does not talk over your screen reader.
--exit-after SECONDSquit cleanly after this long, for unattended runs.

A game started with --endless or --challenge saves its score, kills and stats like any other, so it can legitimately unlock things.

--game

The port does not hard-code where the original's files are: it reads game/ unless --game PATH (or the AUDIODEFENCE_GAME environment variable) names another folder holding the data — one with meta/ and sounds/ inside it. With the game in the repository you never need the option; it is there for the cases where the data is somewhere else:

py AudioDefence.py --game "D:/backups/audiodefence.app"

It accepts the app bundle itself or a folder holding it (audiodefence.app or Payload/audiodefence.app inside). The first line of the log says which path won, so it is easy to tell whether the option took effect:

main INFO game data: D:/backups/audiodefence.app (from the AUDIODEFENCE_GAME environment variable)

Working on the port

The rule the port is written by: never from memory. Before changing a ported method, read the original again — the address is in the comment above it — and compare side by side.

py tools/query.py digest "ADEnemy update:"        pseudo-code for a method
py tools/query.py digest "ADWeapon " 90           a whole class, skipping tiny accessors
py tools/query.py fn "ADPlayer update:"           the full annotated listing
py tools/query.py sel setHeadOrientation:         who sends a selector
py tools/query.py callers "ADBrickManager loadNextBrick"
py tools/query.py str "brick_chance"              who references a string
py tools/query.py ivar "ADEnemy._life"            who reads / writes an ivar
py tools/query.py const 0.8                       who loads a float constant
py tools/listing.py "ADWeapon fire" 0x100 0x200   a range of one listing
py tools/nib_layout.py --all ADMainMenuViewController   a screen's frames and labels
py tools/verify_stats.py                          every weapon and enemy vs the plists

The digests are condensed and sometimes drop code that matters — when a branch does not add up, read the .s listing for the same function. Annotation conventions: [receiver selector:args] message sends, self->ivar field access, tN = … temporaries, if (cond) goto loc_… branches, switch (…) decoded jump tables, one of {…} where a value depends on the path taken. Integer division by constants appears in compiled form — §2 of docs/GAME_STRUCTURE.md has the patterns.

Screens are built from the NIB dumps in analysis/data/nibs/: the port uses the iPhone tag-2781 layout, and the Accessible_* NIB wherever the original has one, because the game swaps those in when VoiceOver is running.

When something has to differ from the original it goes in the Divergences section of docs/PORTING_NOTES.md; when a bug of the original's is kept on purpose it goes in the quirks section. Both say why.

Everything in the repository is stored with LF endings (.gitattributes says so, and game/, vendor/ and analysis/bin/ are marked binary so not a byte of them is touched). The tools write LF too, whatever machine they run on, so regenerating analysis/ produces no line-ending churn to commit.

Rebuilding and recovering

Everything except audiodefence/, tools/, docs/ and game/ can be regenerated. Run these from the project root.

The whole analysis — needs capstone (and av for the sound durations):

py tools/macho.py extract game/audiodefence analysis/bin
py tools/objc.py analysis/bin/audiodefence_arm64
py tools/disasm.py analysis/bin/audiodefence_arm64 --out analysis
py tools/query.py make-digests
py tools/resources.py game --out analysis/data
py tools/extract_hrtf.py analysis/bin/audiodefence_arm64

The HRTF, if assets/hrtf/audiodefence_ircam1050.mhr is deleted:

py tools/build_hrtf.py

It rebuilds the file from analysis/data/embedded_hrtf.dat and prints the interaural level difference and delays at five angles as a sanity check — a sound on the right should be louder and earlier in the right ear. Add --verify to replay the original's panner maths for every angle and compare the two (non-zero exit code if they disagree); the file written is the same either way. If analysis/data/embedded_hrtf.dat is gone as well, extract it first — and note that the extractor needs the thin arm64 slice, not the fat game/audiodefence:

py tools/macho.py extract game/audiodefence analysis/bin
py tools/extract_hrtf.py analysis/bin/audiodefence_arm64      needs capstone
py tools/build_hrtf.py

Building an executable

py -m pip install pyinstaller

Then double-click compiler.py in Explorer, or type py compiler.py on its own. It asks which build you want:

1. Release build: file the changelog under the version, build, and zip
2. Test build: build, zip, then run it for ten seconds and check its log
3. Build without the zip
4. Clean build: empty PyInstaller's cache first, for when a build behaves oddly
5. Build with a console window, to see why the game will not start
6. One-file build: a single executable instead of a folder
7. Build without the game's data
8. Show what a release build would do, without building anything
0. Quit

Type the number and press Enter. The window waits for Enter at the end, so you can hear how it went before it closes.

That is the whole build. The script checks what it needs, runs PyInstaller with the right arguments, copies the game's data next to the executable and says where the result is: dist\AudioDefence\AudioDefenceOriginal.exe, in a folder that runs on a machine with no Python on it at all.

Each choice is one of the options in the table below, and they still work typed out — py compiler.py --test builds straight away with no menu. Run with no options and nothing to type into — from a script — it goes straight to the release build.

PyInstaller is the only extra package, and it goes in the same Python you play with: a build is made by following the game's own imports, so pygame-ce, numpy, av, comtypes and prismatoid have to be installed there too. prismatoid is optional to play from source but not to build: a release has to carry it, or JAWS players would hear SAPI 5. The script names anything that is missing and stops rather than building half a game. 64-bit Python on Windows produces a 64-bit Windows executable, and only that — there is no cross-compiling to another system.

optionwhat it does
--onefileone executable instead of one folder — see below before you reach for it
--no-gamedo not copy the game's data; the build then needs --game PATH to find it
--consolekeep a console window beside the game, where a failed start-up prints its traceback
--cleanempty both of PyInstaller's working places first — this project's build\ folder and the shared cache in %LOCALAPPDATA%\pyinstaller — when a rebuild behaves oddly. dist\ is untouched, and so is everything in the repository
--testrun the result for ten seconds afterwards and read its log: that it found the game data, that the game's own HRTF is in use, and that it ended without a traceback
--dry-runprint what would happen, build nothing — including every file that would land beside the executable
--no-packagedo not make the zip. A build otherwise ends by packing dist\AudioDefence into dist\AudioDefenceOriginal-Win-<version>.zip, which is what a release's asset is, warning first if VERSION is missing or changelog.txt still starts with unrelease:

Cutting a release

The archive is a zip, not a rar: it is what Python's own zipfile makes and what every player can open without installing an extractor. Every build makes it, unless you choose the build without the zip (--no-package).

New changes go in changelog.txt under one heading at the top, unrelease:, one line each. You never rename that heading yourself — the build files it. In order:

The release build — choice 1, or py compiler.py with no options from a script — does three things to the repository before it copies anything:

The copy of the changelog beside the executable is the same, less the empty unrelease: line, so it opens on the newest version. Every version's lines are followed by one blank line, so where one version ends is something you hear. Nothing under unrelease: means nothing is moved and the repository's changelog is not touched. Building twice without committing is harmless: the second build finds unrelease: already empty.

Every other choice — the test build, the clean build, the build without the zip and the rest, or any of their options typed out — leaves the changelog exactly as it is, because those builds are for trying something, not for releasing it, and they end by saying there is no need to commit. If such a build is zipped, it says so: its changelog still opens with unrelease:. Choice 8, Show what a release build would do, reads out what the release build would do to the changelog without writing anything.

VERSION is YY.MM.DD-XX: last two digits of the year, month, day, and which release of that day it is, counting from 1. The first release on the 20th of September 2026 is 26.09.20-1; a second one the same day is 26.09.20-2. The compiler never works the number out: it follows whatever you wrote in VERSION, and the changelog entry is named the same.

The build carries the number inside the executable rather than beside it: the compiler reads VERSION from the repository and compiles it in. So there is no VERSION file in a built game's folder, and nothing a player edits or deletes there can change what the game thinks it is. A VERSION file an older build left beside the executable is ignored. You only ever change the number in the repository's VERSION. A build made with an option while the repository has no VERSION file carries no number at all; the release build never does this, because it starts the file first. Run from source, the game does the same as a release build when there is no VERSION file: it makes one, at today's first release.

A build also carries three pieces of text beside the executable: changelog.txt, license.txt (the repository's LICENSE, renamed so Windows opens it without asking what with), and readme.html — this file, converted at build time by tools/md_to_html.py, which needs nothing installed. HTML rather than Markdown because a screen reader moves through it by heading, table and list, where a .md file reads every # and | aloud. The page is not committed, so it cannot drift from this one; if the Markdown ever grows something the converter does not know — a fenced code block, a numbered list — the build says so and writes no page rather than shipping one with holes in it. py tools/md_to_html.py --check README.md asks the same question at any time.

What a build carries, and what it does not

The port, the HRTF and the two vendored DLLs go inside the build. game/ does not — and game/ is the game's audio: the 918 sound files under game/sounds/ (the narration, the zombies, the weapons, the music), the playlists under game/meta/ that arrange them, the plists and the strings. Every one of those is Somethin' Else's recording, not the port's, and a build carries them with it.

The script copies game/ next to the executable — AudioDefenceOriginal.exe and game/ side by side — which is where audiodefence/paths.py looks when frozen, and --game PATH (AUDIODEFENCE_GAME) still overrides it. Build with --no-game and you get the port alone: no sounds, silent until it is pointed at a copy of the original. Everything else is read out of the bundle PyInstaller unpacks, so assets/hrtf and vendor/ need no copy.

changelog.txt is copied next to the executable as well, so whoever plays the build can read what changed without the repository.

One folder, not one file

The default is a folder, and that is the right shape here. --onefile gives a single executable that unpacks its whole payload — Python, pygame, numpy and av's FFmpeg, well over 100 MB of it — into %TEMP% at every launch, which is seconds of silence before the game says anything. The one-folder build starts at once. (One file does work: the game's data is still looked for beside the executable, not inside the payload.)

Checking the result

py compiler.py --test

After building it starts the game for ten seconds and reads %APPDATA%\AudioDefenceOriginal\audiodefence.log for the three things that matter: that the first line says game data: … (from the game folder next to the executable) and not (missing); that game HRTF audiodefence_ircam1050 not in use does not appear, which would mean assets/hrtf never made it into the bundle and OpenAL has quietly substituted its own; and that nothing ended in a traceback. The same run by hand is dist\AudioDefence\AudioDefenceOriginal.exe --exit-after 10 --log-level debug.

What no script can check for you is the sound. Start it normally and listen to the main menu — once with NVDA, and once with NVDA closed so that SAPI is exercised. (SAPI goes through comtypes, which builds its COM wrappers in memory in a frozen program instead of writing them to disk, so it is worth hearing rather than assuming.) If you have another screen reader, such as JAWS, listen once with that too: it goes through Prism, whose compiled half the build carries in _internal\prism\_native.

If something is missing from the build

av, comtypes and Prism (prism) are all imported the first time they are needed rather than at the top of a module (s3d/decoder.py, platform/speech.py), so they are what PyInstaller's analysis is likeliest to walk past. The script already names them outright — --collect-all av, --collect-submodules comtypes, --collect-all prism — so the usual failures are covered. Prism needs two more: its compiled Python module, prism\_native\_prism_cffi.pyd, sits in a folder that is not a package, so --collect-all leaves it behind and the script adds it by name; and --hidden-import _cffi_backend, which that module needs and nothing names. For anything else that turns up as a ModuleNotFoundError in a frozen run, add --hidden-import NAME to the list in command() in compiler.py.

No console window

The script passes --windowed, so the built game is one window and not two: a console alongside it would be another thing in the alt-tab order announcing itself, for nothing. What a console is good for is the moment start-up fails, so _report_failure in AudioDefence.py does that job without one — it writes the traceback to %APPDATA%\AudioDefenceOriginal\crash.txt and speaks a line saying so, and falls back to printing and waiting for Enter when there is a console to print in. Build with --console to get one; a run from source has had one all along.

Afterwards

build/ is PyInstaller's scratch folder, dist/ is its output and AudioDefenceOriginal.spec is the file it writes from the arguments above; the script regenerates all three, and none of them belongs in the repository. The reverse engineering lives in analysis/ precisely so that build/ stays free.

An unsigned executable is not something Windows trusts: expect SmartScreen to warn the first time it runs on another machine, and antivirus to hold that first start while it scans. Signing is the only real cure.

Troubleshooting

Everything the port does is logged to %APPDATA%\AudioDefenceOriginal\audiodefence.log (--log-level debug for more). If it fails before it can play anything, the traceback is spoken and written to crash.txt in that same folder.

Positioning feels flat, or sounds come from the wrong place. Look for:

s3d ERROR game HRTF audiodefence_ircam1050 not in use (found=False, status=…)

OpenAL quietly substitutes its own HRTF when the game's is missing. Rebuild it as above; no such line means the game's own HRTF is loaded.

"the game's data is not where the port looks for it". game/ is empty or in the wrong shape: it needs meta/ and sounds/ at its top level. Point --game at another copy if it lives elsewhere.

Nothing is spoken. With NVDA running the port talks to it through vendor/nvda/nvdaControllerClient64.dll; with another screen reader it goes through Prism, which needs prismatoid; with none it falls back to SAPI 5, which needs comtypes. The log says which one it took ("speech: JAWS, through Prism", or "Prism not available" when it could not load it).

A screen reads in an odd order. The reading order is reconstructed from the NIB frames (audiodefence/ui/accessibility.py), so it approximates VoiceOver's rather than copying it. Check that screen's frames with tools/nib_layout.py --all.

Credits

Audio Defence: Zombie Arena was made by Somethin' Else, and everything worth hearing in this port is theirs. The Papa Engine that carries it announces itself as "Somethin' Else Papa Engine" when it starts, and the source paths still inside the binary point at Papa Sangre, which is a nice thing to find at two in the morning.

The game's own Credits screen, under Info, says so too: the nib lists everyone who made it but never names the studio, so the port puts Audio Defence: Zombie Arena, by Somethin' Else above that list, and adds the port's own credits and this repository's address in a section after it.

This port, on the other hand:

Claude — Opus 5, highest effort. Wrote every line of the port. Read the disassembly, ported it method by method, argued about tail calls, and was wrong twice in one evening about an ambience loop before a human with working ears put it right. Without it this would have stayed an idea. Occasionally over-confident, reliably apologetic.

Loh Boon Keat — the prompter. Directed the whole thing, made every judgement call about what to fix and what to leave alone, held the line on "compare it with the original, don't rely on your memory" until it stuck, and play-tested a game he was building at the same time.

Wong Wee Xiang — the tester, and the reason this list of fixes is as long as it is. Found the Tactical Rifle cutting itself off, the death screen that would not let go, the Chainsaw ambience that outlived the game, the rewards paid twice, and the quiet round after pressing Try again. If you enjoy a bug being fixed before you meet it, thank him.

Muhammad Hajjar — who inspired the project and worked out how to point an AI at a shipped iOS binary and get a Windows game back out of it. The concept is his; the rest is bookkeeping. He now writes the port as well, with the same Claude Opus 5 at the same highest effort, so from here there are two people directing it and two sets of commits.

Licence

The port's own code — everything in audiodefence/, tools/, compiler.py and the documentation — is the author's to license.

game/ is the original game: Somethin' Else's data, audio and layouts. It is not ours and it is not covered by the port's licence. analysis/ is derived from their binary and is in the same position. A build produced by compiler.py contains all of it.

This project is not affiliated with Somethin' Else, and no claim is made to their work.